From 0b241c03e509f9c975ed2bd0293e6c9989af57d1 Mon Sep 17 00:00:00 2001
From: Christoph Berg <myon@debian.org>
Date: Wed, 29 Jul 2026 14:22:55 +0200
Subject: [PATCH v7 5/5] Use ImageBinaryField for the badges

---
 pgweb/contributors/forms.py                   |  2 +-
 ...ve_badge_image_badge_imagedata_and_more.py | 41 +++++++++++++++++++
 pgweb/contributors/models.py                  |  5 ++-
 pgweb/contributors/views.py                   | 17 ++++++++
 pgweb/urls.py                                 |  1 +
 templates/account/userprofileform.html        |  2 +-
 templates/base/form_contents.html             |  2 +-
 templates/contributors/badge.html             |  2 +-
 templates/contributors/people.html            |  2 +-
 templates/contributors/profile.html           |  2 +-
 10 files changed, 68 insertions(+), 8 deletions(-)
 create mode 100644 pgweb/contributors/migrations/0005_remove_badge_image_badge_imagedata_and_more.py

diff --git a/pgweb/contributors/forms.py b/pgweb/contributors/forms.py
index afa57d0e..a2bc587f 100644
--- a/pgweb/contributors/forms.py
+++ b/pgweb/contributors/forms.py
@@ -32,7 +32,7 @@ class BadgeForm(forms.ModelForm):
         {
             'id': 'general',
             'legend': 'Contributor Badge',
-            'fields': ['org', 'badge', 'description', 'url', 'image', 'contact', ],
+            'fields': ['org', 'badge', 'description', 'url', 'imagedata', 'contact', ],
         },
         {
             'id': 'holders',
diff --git a/pgweb/contributors/migrations/0005_remove_badge_image_badge_imagedata_and_more.py b/pgweb/contributors/migrations/0005_remove_badge_image_badge_imagedata_and_more.py
new file mode 100644
index 00000000..7a1663eb
--- /dev/null
+++ b/pgweb/contributors/migrations/0005_remove_badge_image_badge_imagedata_and_more.py
@@ -0,0 +1,41 @@
+# Generated by Django 5.2.16 on 2026-07-28 15:58
+
+import pgweb.util.fields
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("contributors", "0004_badge_alter_contributor_ctype_and_more"),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name="badge",
+            name="image",
+        ),
+        migrations.AddField(
+            model_name="badge",
+            name="imagedata",
+            field=pgweb.util.fields.ImageBinaryField(
+                blank=True,
+                help_text="Square image, will be auto-scaled to 150×150. JPEG or PNG, up to 1 MB.",
+                max_length=1000000,
+                null=True,
+                verbose_name="Image",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="badge",
+            name="holders",
+            field=models.ManyToManyField(
+                blank=True,
+                through="contributors.Badgeholder",
+                through_fields=("badge", "user"),
+                to=settings.AUTH_USER_MODEL,
+            ),
+        ),
+    ]
diff --git a/pgweb/contributors/models.py b/pgweb/contributors/models.py
index 028b6bb3..11589d36 100644
--- a/pgweb/contributors/models.py
+++ b/pgweb/contributors/models.py
@@ -3,6 +3,7 @@ from django.contrib.auth.models import User
 from pgweb.core.models import Organisation
 from pgweb.core.text import ORGANISATION_HINT_TEXT
 from pgweb.util.moderation import TwostateModerateModel
+from pgweb.util.fields import ImageBinaryField
 
 
 class ContributorType(models.Model):
@@ -50,14 +51,14 @@ class Badge(TwostateModerateModel):
     badge = models.CharField(max_length=32, null=False, blank=False, unique=True, help_text='Title of this badge, e.g. "PGConf.EU 2025 Speaker".')
     description = models.TextField(null=True, blank=True, help_text='What did the people do who contributed here?')
     url = models.URLField(max_length=100, null=True, blank=True, verbose_name='Contribution URL', help_text='URL for this contribution, e.g. the conference homepage. (Leave blank when there is no URL.)')
-    image = models.CharField(max_length=100, verbose_name='Path to contribution image', null=True, blank=True, help_text="Badge images should be square (usually shown at 150x150 pixels). When left blank, the Slony logo will be used. External URLs work, but preferably the image should be hosted on postgresql.org. Mail the Contributors team to have your image added.")
+    imagedata = ImageBinaryField(max_length=1000000, resolution=(150, 150), auto_scale=True, blank=True, null=True, verbose_name="Image", help_text="Square image, will be auto-scaled to 150×150. JPEG or PNG, up to 1 MB. When left blank, the Slony logo will be used.")
     contact = models.CharField(max_length=100, null=True, blank=True, verbose_name='Contact address', help_text='Contact address (email, URL, other) for people who want to be added as badge holder')
     holders = models.ManyToManyField(User, through="Badgeholder", through_fields=("badge", "user"), blank=True)
 
     sortorder = models.IntegerField(null=True, blank=True, default=100)
 
     account_edit_suburl = 'badges'
-    moderation_fields = ['badge', 'description', 'url', 'image']
+    moderation_fields = ['badge', 'description', 'url']
     send_notification = True
     purge_urls = ('/community/people/', '/community/badge/')
 
diff --git a/pgweb/contributors/views.py b/pgweb/contributors/views.py
index e22136b3..f439671c 100644
--- a/pgweb/contributors/views.py
+++ b/pgweb/contributors/views.py
@@ -1,6 +1,10 @@
+import hashlib
+
 from django.shortcuts import get_object_or_404
+from django.http import HttpResponse, HttpResponseNotModified, Http404
 
 from pgweb.util.contexts import render_pgweb
+from pgweb.util.image import get_image_contenttype_from_bytes
 
 from .models import ContributorType, Contributor, Badge, Badgeholder
 
@@ -40,3 +44,16 @@ def profile(request, username):
         'contributor': contributor,
         'badges': badges,
     })
+
+
+def badge_image(request, badgeid):
+    badge = get_object_or_404(Badge.objects.only('id', 'imagedata'), id=badgeid, approved=True)
+    if not badge.imagedata:
+        raise Http404
+    etag = '"' + hashlib.md5(bytes(badge.imagedata)).hexdigest() + '"'
+    if request.headers.get('If-None-Match') == etag:
+        return HttpResponseNotModified()
+    return HttpResponse(
+        bytes(badge.imagedata),
+        content_type=get_image_contenttype_from_bytes(badge.imagedata[:8]),
+    )
diff --git a/pgweb/urls.py b/pgweb/urls.py
index 50eb4acc..8bbdfdf6 100644
--- a/pgweb/urls.py
+++ b/pgweb/urls.py
@@ -76,6 +76,7 @@ urlpatterns = [
     re_path(r'^community/people/$', pgweb.contributors.views.peoplelist),
     re_path(r'^community/people/([^/]+)/$', pgweb.contributors.views.profile),
     re_path(r'^community/badge/(\d+)/$', pgweb.contributors.views.badge_view),
+    re_path(r'^community/badge/(\d+)/image/$', pgweb.contributors.views.badge_image),
 
     re_path(r'^community/lists/$', RedirectView.as_view(url='/list/', permanent=True)),
     re_path(r'^community/lists/subscribe/$', RedirectView.as_view(url='https://lists.postgresql.org/', permanent=True)),
diff --git a/templates/account/userprofileform.html b/templates/account/userprofileform.html
index 4d6fc04a..c6c49e52 100644
--- a/templates/account/userprofileform.html
+++ b/templates/account/userprofileform.html
@@ -100,7 +100,7 @@
     {%for b in badges %}
       <figure class="badges-cell">
         <a href="/community/badge/{{b.id}}/">
-        <img src="{%if b.image %}{{b.image}}{%else%}/media/img/about/press/elephant.png{%endif%}">
+        <img src="{%if b.imagedata %}/community/badge/{{b.id}}/image/{%else%}/media/img/about/press/elephant.png{%endif%}">
         <figcaption>{{b}}</figcaption>
         </a>
       </figure>
diff --git a/templates/base/form_contents.html b/templates/base/form_contents.html
index 9a00c216..4f9b4263 100644
--- a/templates/base/form_contents.html
+++ b/templates/base/form_contents.html
@@ -1,5 +1,5 @@
 {%load pgfilters%}
-<form class="form-horizontal" method="post" action=".">{%if not nocsrf%}{% csrf_token %}{%endif%}
+<form class="form-horizontal" method="post" action="."{% if form.is_multipart %} enctype="multipart/form-data"{% endif %}>{%if not nocsrf%}{% csrf_token %}{%endif%}
   {%if form.errors %}
     <div class="alert alert-danger">
       Please correct the errors below, and re-submit the form.
diff --git a/templates/contributors/badge.html b/templates/contributors/badge.html
index 9d07b28e..0e2cee40 100644
--- a/templates/contributors/badge.html
+++ b/templates/contributors/badge.html
@@ -3,7 +3,7 @@
 {%block title%}{{badge}} - Contribution{%endblock%}
 {%block contents%}
 <h1>Badge: {{badge}} <i class="fa fa-star"></i></h1>
-<p><img src="{%if badge.image %}{{badge.image}}{%else%}/media/img/about/press/elephant.png{%endif%}" width="150"></p>
+<p><img src="{%if badge.imagedata %}/community/badge/{{badge.id}}/image/{%else%}/media/img/about/press/elephant.png{%endif%}" width="150"></p>
 
 {% if badge.description or badge.url %}
 <h2>Contribution</h2>
diff --git a/templates/contributors/people.html b/templates/contributors/people.html
index 20950de3..ce192cd7 100644
--- a/templates/contributors/people.html
+++ b/templates/contributors/people.html
@@ -26,7 +26,7 @@
 {%for b in badges %}
     <figure class="badges-cell">
         <a href="/community/badge/{{b.id}}/">
-        <img src="{%if b.image %}{{b.image}}{%else%}/media/img/about/press/elephant.png{%endif%}">
+        <img src="{%if b.imagedata %}/community/badge/{{b.id}}/image/{%else%}/media/img/about/press/elephant.png{%endif%}">
         <figcaption>{{b}}</figcaption>
         </a>
     </figure>
diff --git a/templates/contributors/profile.html b/templates/contributors/profile.html
index 674c2648..fc2eaabc 100644
--- a/templates/contributors/profile.html
+++ b/templates/contributors/profile.html
@@ -12,7 +12,7 @@
       {%for b in badges %}
         <figure class="badges-cell">
           <a href="/community/badge/{{b.badge.id}}/">
-          <img src="{%if b.badge.image %}{{b.badge.image}}{%else%}/media/img/about/press/elephant.png{%endif%}">
+          <img src="{%if b.badge.imagedata %}/community/badge/{{b.badge.id}}/image/{%else%}/media/img/about/press/elephant.png{%endif%}">
           <figcaption>{{b.badge}}{%if b.date_retired%}<br><em>(retired)</em>{%endif%}</figcaption>
           </a>
         </figure>
-- 
2.53.0

